Make propose and commit agree on time-varying authority - #107
Merged
b-macker merged 1 commit intoJul 30, 2026
Conversation
#105 established that every check whose truth can change with the passage of time belongs on the commit half, because authority is time-varying and a proposal is not. Two holes in that invariant remained, both on this path. Config changes were not re-checked across the propose→commit gap. commit() deliberately does not call reloadIfChanged() — an accepted reload runs onAgentConfigChanged(), which re-baselines mandate_keywords and would score an already-generated candidate against a mandate it never received, besides dangling `config` through the atomic rules_ptr_ swap. That decision was right, but its consequence was left open: a candidate produced under one configuration could still be committed under another. Proposals now carry the accepted-config generation they were evaluated under, and commit refuses when it has moved — declining to commit under conditions the candidate never saw, rather than re-scoring it under them. reload_count_ and not governance_epoch_: the epoch also moves on pulse verdict transitions, and the pulse sawtooths, so proposals would be voided during normal degraded operation. That stamp is not additive. reload_count_ was a plain int while governance_epoch_ beside it is atomic; it is written under reload_mutex_ but getReloadCount() reads it unsynchronised, so reading it from agent worker threads during batch/fan_out would have been a data race. It is now std::atomic<int>. The accessor had no callers, so the change is contained to four sites. Second hole: agentPropose's lease test sat inside `if (cb.step_up_enabled)`, and step_up_enabled defaults to false — so by default propose did not consult the lease at all, while send() hard-throws on an expired lease and commit() checks it unconditionally. Propose was the only entry point where expired authority passed silently. The lease test is now unconditional; the level test stays inside the guard because required_level derives from step_up_at_level and means nothing when step-up is off. Refusal wording matches the two existing shapes so callers keep matching on them. No config in the repo combines propose with a lease and step-up off — the two propose configs with step-up off have no lease at all, so the hoisted check is a no-op for them. The change reaches only the configuration nobody was using, which is the hole. Groups H and I cover both. Two mechanics cost real time to find and are recorded in the test: reloadIfChanged() compares mtime at second granularity, so a rewrite in the same second is invisible; and subprocess env is scrubbed, so the re-sign must be passed --signing-key explicitly. Vacuity-checked: disabling the stamp fails H-01 while H-02 still passes, restoring the old nesting fails I-01 while I-02 and I-03 still pass. Full suite: 441 tests, 0 unexpected failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELUfjXZvx8kzXo1UJjrAhC
NAAb Governance Report
All governance checks passed! Generated by NAAb Governance Engine v4.0 |
b-macker
marked this pull request as ready for review
July 30, 2026 23:43
This was referenced Jul 31, 2026
b-macker
added a commit
that referenced
this pull request
Aug 1, 2026
checkAdmission() denies at CRITICAL with a HARD block — "all autonomous actions suspended until pressure subsides" — and agent.propose() is gated on it through that call. agent.commit() re-checked the handle, max_turns, the lease (#105) and the config generation (#107), but never the level. Commit is where the proposed transition becomes real: history is appended and the turn advances. So a candidate generated at NORMAL could still land after the engine reached CRITICAL. The proposing handle need do nothing for this to happen. governance_level_ is a single engine-global atomic written from CDD processing, driven by whichever handle last took a turn, while s_pending_proposals is keyed per handle — so only that handle's own activity invalidates its proposal. A sibling agent's misbehaviour therefore escalates across the deliberation gap while the proposal stays valid, which is exactly the shape batch, fan_out and pipelines run in. The reasoning that justifies commit's other omissions does not extend here, and that is the trap worth naming: hard_stopped and recordAutonomousAction are left out because they guard SPEND and commit makes no API call. A CRITICAL suspension is not a spend guard. Hence checkCriticalSuspension(), the CRITICAL branch split out of checkAdmission() into its own public method, rather than calling checkAdmission() wholesale — the latter also projects one more autonomous action against max_autonomous_actions and would refuse a commit that costs nothing. The refusal is a GovernanceHardError, matching admission's enforcement at propose. Same invariant #105 and #107 established: a check whose truth changes with the passage of time belongs on the commit half. Group J covers it, with J-02 as the control — without escalation the same commit must succeed, or J-01 shows only that commits fail. Two staging traps are pinned in the test and the docs because both produced a green that meant nothing: The escalation is two thresholds in series. consecutive_high_pressure_turns only increments while composite >= reality_checkpoint.pressure_threshold, and the CRITICAL target needs composite >= critical_threshold AND consecutive >= critical_sustained. Setting only the circuit-breaker half pins the counter at zero and the level never moves. The first working version passed with the fix reverted. The sibling sent twice, and the second send's own checkAdmission() hard-blocked once the first had escalated — the process died before the commit, stderr still said CRITICAL, and the assertion matched a blocked sibling send rather than the gate under test. One send plus a PRE_COMMIT marker distinguishes "blocked earlier" from "blocked at commit". Live status: stub-only. No live run has reached CRITICAL with a proposal outstanding, so the firing path has not executed against a real API. Claude-Session: https://claude.ai/code/session_01ELUfjXZvx8kzXo1UJjrAhC Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#105 established the invariant: every check whose truth can change with the passage of time belongs on the commit half, because authority is time-varying and a proposal is not. Two holes in that invariant remained, both on the propose/commit path.
A1 — proposals now carry the config generation they were evaluated under
agent.commit()deliberately does not callreloadIfChanged()— traced and rejected in #105, because an accepted reload runsonAgentConfigChanged(), which re-baselinesmandate_keywordsand would score an already-generated candidate against a mandate it never received, besides danglingconfigthrough the atomicrules_ptr_swap. That decision was right, but its consequence was left open: a candidate produced under one configuration could still be committed under another.Proposals are now stamped with
reload_count_at propose time and commit refuses when it has moved — declining to commit under conditions the candidate never saw, rather than re-scoring it under them.reload_count_and notgovernance_epoch_: the epoch also increments on pulse verdict transitions (governance_engine.cpp:6906), and the pulse sawtooths, so proposals would be voided during normal degraded operation.This was not additive.
reload_count_was a plainint(governance.h:3478) whilegovernance_epoch_beside it isstd::atomic<int>. It is written underreload_mutex_butgetReloadCount()read it unsynchronised — reading it from agent worker threads duringbatch/fan_outwould have been a data race. It is now atomic. The accessor had no callers, so the change is contained to four sites.A2 — propose consults the lease even with step-up disabled
agentPropose's lease test sat insideif (cb.step_up_enabled), andstep_up_enableddefaults to false (governance.h:1600). So by default propose did not consult the lease at all, whileagent.send()hard-throws (agent_impl.cpp:1461) andagent.commit()checks it unconditionally (#105). Propose was the only entry point where expired authority passed silently.The lease test is now unconditional. The level test stays inside the guard —
required_levelderives fromstep_up_at_level, so it is intrinsically about step-up and means nothing when step-up is off. Refusal wording matches the two existing shapes so callers keep matching on them.Regression surface is empty, verified. Only four configs in the repo enable propose:
test_split_commit.shtest_absorption_degenerate.shtest_propose_commit.shliving-script_extended/src/govern.jsonNothing combines propose + a lease + step-up off. The two with step-up off have no lease, so
lease_configuredis false and the hoisted check is a no-op for them. The change reaches only the configuration nobody was using — which is the hole.Two mechanics that cost real time to find
Both are recorded in the test, since anyone writing a similar case will hit them:
reloadIfChanged()compares mtime at second granularity (governance_config.cpp:3753). A rewrite landing in the same second as the initial load is invisible — the first attempt at H-01 silently did nothing.--signing-keyexplicitly;NAAB_SIGNING_KEYdoes not survive into the child. This is the same workaroundliving-script.naabalready uses.Test Plan
test_propose_commit.sh— 23/23, Groups A–G unchanged, H and I newstep_up_enablednesting restored → I-01 fails, I-02 and I-03 still pass (the no-op and anti-regression controls survive)tests/security/test_error_msg_leaks.sh— 874/0 (new refusal text)bash run-all-tests.sh— 441 tests, 0 unexpected failuresreload_count_atomic change being wrong. Worth watching on this PR specifically.Honest scoping
The reload stamp will rarely fire in
living-script_extended— nothing between propose,select_admissibleand commit triggers a reload there, and the one path that does send (the #106 re-auth retry) already invalidates the proposal. This is an invariant, not a detector. It earns its place in multi-agent scripts where a sibling agent's send, a polyglot block (polyglot.cpp:158) or the VM (vm.cpp:2815) reloads mid-gap.PR B (example taint/integrity coverage) is deliberately separate and does not depend on this.
Generated by Claude Code